Skip to content

feat: use ordered map for always deterministic transaction serialization#364

Closed
JDeuce wants to merge 2 commits intosolana-foundation:mainfrom
JDeuce:feat/ordered-map-deterministic
Closed

feat: use ordered map for always deterministic transaction serialization#364
JDeuce wants to merge 2 commits intosolana-foundation:mainfrom
JDeuce:feat/ordered-map-deterministic

Conversation

@JDeuce
Copy link
Copy Markdown

@JDeuce JDeuce commented Mar 17, 2026

Summary

Transaction serialization with address lookup tables (ALTs) is non-deterministic today because two internal map types are iterated in random Go map order:

  1. options.addressTables — when an address appears in multiple tables, which table "wins" varies between runs
  2. lookupsMap — the order lookup table entries are appended to the serialized message varies between runs

This is an alternative approach to #344 that solves the same problem by replacing the internal maps with github.com/wk8/go-ordered-map/v2.

Changes

New file: address_table_map.go

Helper functions for converting between map[PublicKey]PublicKeySlice, []AddressTableEntry, and the internal *orderedmap.OrderedMap. Centralised here because they are referenced from both transaction.go and message.go. The orderedmap type does not appear in any public API.

transaction.go

  • transactionOptions.addressTables is now *orderedmap.OrderedMap — preserves insertion order
  • TransactionAddressTables(map) — signature unchanged; map is converted to an ordered map internally (arbitrary order, same as before)
  • AddressTableEntry — new type: { TableKey PublicKey; Addresses PublicKeySlice }
  • TransactionAddressTablesSlice([]AddressTableEntry) — new option; caller controls table order and therefore which table takes priority for shared addresses

message.go

  • Message.addressTables field is now *orderedmap.OrderedMap — nil-safe, preserves insertion order
  • SetAddressTables(map) — signature unchanged; converts to ordered map internally
  • SetAddressTablesSlice([]AddressTableEntry) — new; ordered setter matching TransactionAddressTablesSlice
  • GetAddressTables() map — signature unchanged; converts back to plain map on the way out
  • GetAddressTablesSlice() []AddressTableEntry — new; returns tables in insertion order

Tests

  • TestNewTransactionWithAddressLookupTables_Deterministic — 100-iteration test using TransactionAddressTablesSlice with two overlapping tables (shared address), asserts byte-identical serialization every run
  • 8 unit tests in address_table_map_test.go covering round-trips, empty inputs, nil-safety, order preservation, and shared-address priority

Compared to #344

PR #344 This PR
Deterministic by default No — opt-in via TransactionDeterministicOrdering() TransactionAddressTablesSlice is explicitly ordered; TransactionAddressTables preserves existing behaviour
New dependency No github.com/wk8/go-ordered-map/v2
Caller controls table priority No Yes — slice position determines which table wins for shared addresses
Message API additions No SetAddressTablesSlice, GetAddressTablesSlice

The tradeoff is one new transitive dependency in exchange for a richer, explicit API that makes ordering a first-class concept rather than a hidden sort.

@daog1
Copy link
Copy Markdown

daog1 commented Mar 18, 2026

I dug into #364 alongside #344 and found one place where the implementation does not currently match the API/story in the PR description.

TransactionAddressTablesSlice() does control which table wins when the same address appears in multiple tables, because addressLookupKeysMap is populated by iterating options.addressTables in slice order:

for pair := options.addressTables.Oldest(); pair != nil; pair = pair.Next() {
    addressTablePubKey, addressTable := pair.Key, pair.Value
    for i, address := range addressTable {
        if _, ok := addressLookupKeysMap[address]; ok {
            continue
        }
        addressLookupKeysMap[address] = addressTablePubkeyWithIndex{
            addressTable: addressTablePubKey,
            index:        uint8(i),
        }
    }
}

However, the final Message.AddressTableLookups order is not built from that same ordering source. Right now it is built by iterating lookupsMap, whose insertion order depends on when a table is first encountered while scanning allKeys:

for idx, acc := range allKeys {
    addressLookupKeyEntry, isPresentedInTables := addressLookupKeysMap[acc.PublicKey]
    _, isInvoked := programIDsMap[acc.PublicKey]
    if isPresentedInTables && idx != 0 && !acc.IsSigner && !isInvoked {
        lookup, _ := lookupsMap.Get(addressLookupKeyEntry.addressTable)
        if acc.IsWritable {
            lookup.WritableIndexes = append(lookup.WritableIndexes, addressLookupKeyEntry.index)
            lookup.Writable = append(lookup.Writable, acc.PublicKey)
        } else {
            lookup.ReadonlyIndexes = append(lookup.ReadonlyIndexes, addressLookupKeyEntry.index)
            lookup.Readonly = append(lookup.Readonly, acc.PublicKey)
        }
        lookupsMap.Set(addressLookupKeyEntry.addressTable, lookup)
        continue
    }
}

and later:

for pair := lookupsMap.Oldest(); pair != nil; pair = pair.Next() {
    tablePubKey, l := pair.Key, pair.Value
    lookups = append(lookups, MessageAddressTableLookup{
        AccountKey:      tablePubKey,
        WritableIndexes: l.WritableIndexes,
        ReadonlyIndexes: l.ReadonlyIndexes,
    })
}

That means TransactionAddressTablesSlice() currently gives only partial ordering semantics:

  • it controls shared-address priority
  • it does not necessarily control final lookup serialization order

I verified this locally with a targeted test: given []AddressTableEntry{table1, table2}, table1 still wins for a shared address, but table2 can still be serialized first if one of its accounts is encountered earlier in allKeys.

I think the fix is straightforward: when constructing the final lookups slice, iterate options.addressTables in order and pull matching entries from lookupsMap, instead of iterating lookupsMap directly. That way the same source of truth controls both:

  • shared-address priority
  • final lookup serialization order

Without that change, the current implementation does not fully deliver the ordering guarantees described by TransactionAddressTablesSlice() and the PR description.

@JDeuce
Copy link
Copy Markdown
Author

JDeuce commented Mar 19, 2026

Yeah this is good feedback. Technically the order was deterministic from all the inputs in the previous version but it was not following the input. So I think the latest update, should provide the stronger guarantee you mentioned @daog1

@JDeuce JDeuce marked this pull request as ready for review March 31, 2026 14:54
@HealthyBuilder
Copy link
Copy Markdown
Collaborator

Great chat! Thank you for your contribution!

I saw it covered in #365, closing not, Feel free to open new PR if issues remained.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants